了解WebGL中实时阴影渲染的核心概念和高级技术。本指南涵盖阴影贴图、PCF、CSM以及常见伪影的解决方案。
WebGL阴影贴图:实时渲染的全面指南
在3D计算机图形的世界中,很少有元素比阴影更能增强真实感和沉浸感。它们提供了关于物体之间的空间关系、光源的位置以及场景的整体几何形状的关键视觉线索。如果没有阴影,3D世界会感觉扁平、脱节和虚假。对于由WebGL驱动的基于Web的3D应用程序来说,实现高质量的实时阴影是专业级体验的标志。本指南深入探讨了实现这一目标最基本和最广泛使用的技术:阴影贴图。
无论您是经验丰富的图形程序员,还是涉足三维领域的Web开发人员,本文都将为您提供知识,以了解、实现和排除WebGL项目中实时阴影的故障。我们将从核心理论到实际实现细节,探索常见的陷阱以及现代图形引擎中使用的高级技术。
第一章:阴影贴图的基础知识
从本质上讲,阴影贴图是一种巧妙而优雅的技术,它通过询问一个简单的问题来确定场景中的一个点是否在阴影中:“这个点能被光源看到吗?” 如果答案是否定的,则表示有东西挡住了光线,并且该点必须在阴影中。为了以编程方式回答这个问题,我们使用两遍渲染方法。
什么是阴影贴图?核心概念
整个技术围绕着从不同的角度渲染场景两次:
- 第一遍:深度传递(光的视角)。 首先,我们从光源的精确位置和方向渲染整个场景。但是,我们不关心此过程中的颜色或纹理。我们需要的唯一信息是深度。对于渲染的每个对象,我们记录其与光源的距离。此深度值集合存储在称为阴影贴图或深度图的特殊纹理中。此地图中的每个像素代表从特定方向的光源角度到最近对象的距离。
- 第二遍:场景传递(相机的视角)。 接下来,我们像往常一样从主摄像机的角度渲染场景。但是对于绘制的每个像素,我们都会执行额外的计算。我们确定该像素在3D空间中的位置,然后询问:“该点与光源的距离是多少?” 然后,我们将此距离与阴影贴图(来自第一遍)中相应位置存储的值进行比较。
逻辑很简单:
- 如果像素与光源的当前距离大于阴影贴图中存储的距离,则表示沿着同一视线,有另一个对象更靠近光源。因此,当前像素在阴影中。
- 如果像素距离小于或等于阴影贴图中的距离,则表示没有任何东西挡住它,并且像素被完全照亮。
设置场景
要在WebGL中实现阴影贴图,您需要几个关键组件:
- 光源: 这可以是定向光(如太阳)、点光源(如灯泡)或聚光灯。光的类型将决定深度传递期间使用的投影矩阵的类型。
- 帧缓冲对象 (FBO): WebGL通常渲染到屏幕的默认帧缓冲。要创建阴影贴图,我们需要一个屏幕外渲染目标。FBO允许我们将内容渲染到纹理而不是屏幕。我们的FBO将配置为具有深度纹理附件。
- 两组着色器: 您需要一个用于深度传递的着色器程序(一个非常简单的程序),另一个用于最终场景传递(其中将包含阴影计算逻辑)。
- 矩阵: 您需要相机的标准模型、视图和投影矩阵。至关重要的是,您还需要光源的视图和投影矩阵,通常将它们组合成一个“光空间矩阵”。
第二章:详细的两遍渲染管线
让我们逐步分解两个渲染过程,重点关注矩阵和着色器的作用。
第一遍:深度传递(从光的视角)
此过程的目标是填充我们的深度纹理。这是它的工作原理:
- 绑定 FBO: 在绘制之前,您指示 WebGL 渲染到您的自定义 FBO 而不是画布。
- 配置视口: 设置视口尺寸以匹配阴影贴图纹理的大小(例如,1024x1024 像素)。
- 清除深度缓冲区: 确保在渲染之前清除 FBO 的深度缓冲区。
- 创建光的矩阵:
- 光视图矩阵: 此矩阵将世界转换为光的视角。对于定向光,通常使用 `lookAt` 函数创建此矩阵,其中“眼睛”是光的位置,“目标”是它指向的方向。
- 光投影矩阵: 对于具有平行光线的定向光,使用正交投影。对于点光源或聚光灯,使用透视投影。此矩阵定义了空间中的体积(框或视锥体),它将投射阴影。
- 使用深度着色器程序: 这是一个最小的着色器。顶点着色器的唯一工作是将顶点位置乘以光线的视图和投影矩阵。片段着色器甚至更简单:它只是将片段的深度值(其 z 坐标)写入深度纹理。在现代WebGL中,您甚至不需要自定义片段着色器,因为可以将FBO配置为自动捕获深度缓冲区。
- 渲染场景: 绘制场景中所有投射阴影的对象。FBO 现在包含我们完成的阴影贴图。
第二遍:场景传递(从相机的视角)
现在我们渲染最终图像,使用我们刚刚创建的阴影贴图来确定阴影。
- 取消绑定 FBO: 切换回渲染到默认画布帧缓冲。
- 配置视口: 将视口设置回画布尺寸。
- 清除屏幕: 清除画布的颜色和深度缓冲区。
- 使用场景着色器程序: 这是奇迹发生的地方。此着色器更复杂。
- 顶点着色器: 此着色器必须做两件事。首先,它使用相机的模型、视图和投影矩阵像往常一样计算最终顶点位置。其次,它还必须也使用来自第一遍的光空间矩阵从光的角度计算顶点的位置。第二个坐标作为varying传递给片段着色器。
- 片段着色器: 这是阴影逻辑的核心。对于每个片段:
- 从顶点着色器接收光空间中的插值位置。
- 对此坐标执行透视除法(将 x、y、z 除以 w)。这会将其转换为归一化设备坐标 (NDC),范围为 -1 到 1。
- 将 NDC 转换为纹理坐标(范围为 0 到 1),以便我们可以采样阴影贴图。这是一个简单的缩放和偏移操作:`texCoord = ndc * 0.5 + 0.5;`。
- 使用这些纹理坐标来采样在第一遍中创建的阴影贴图纹理。这给了我们 `depthFromShadowMap`。
- 片段从光线的角度来看的当前深度是其从变换后的光空间坐标中的 z 分量。让我们称它为 `currentDepth`。
- 比较深度: 如果 `currentDepth > depthFromShadowMap`,则片段在阴影中。我们需要在此检查中添加一个小的偏差,以避免一种称为“阴影粉刺”的伪影,我们将在后面讨论。
- 根据比较,确定阴影因子(例如,对于光照为 1.0,对于阴影为 0.3)。
- 将此阴影因子应用于最终颜色计算(例如,将环境光和漫反射光分量乘以阴影因子)。
- 渲染场景: 绘制场景中的所有对象。
第三章:常见问题和解决方案
实现基本的阴影贴图会很快揭示几个常见的视觉伪影。了解并修复它们对于获得高质量的结果至关重要。
阴影粉刺(自阴影伪影)
问题: 您可能会在应该完全照亮的表面上看到奇怪的、不正确的深色线条或莫尔条纹图案。这称为“阴影粉刺”。发生这种情况是因为存储在阴影贴图中的深度值和在场景过程中计算的深度值是针对同一表面的。由于浮点不精确和阴影贴图的有限分辨率,微小的错误会导致片段错误地确定它位于自身之后,从而导致自阴影。
解决方案:深度偏差。 最简单的解决方案是在比较之前向 `currentDepth` 引入一个小的偏差。通过使片段看起来比实际更靠近光线,我们将它“推出”自己的阴影。
float shadow = currentDepth > depthFromShadowMap + bias ? 0.3 : 1.0;
找到正确的偏差值是一项微妙的平衡行为。太小,粉刺仍然存在。太大,您会遇到下一个问题。
彼得潘
问题: 这种伪影以能够飞行并失去阴影的角色命名,表现为物体与其阴影之间存在可见的间隙。它使物体看起来漂浮或与它们应该位于的表面断开连接。它是使用过大的深度偏差的直接结果。
解决方案:斜率比例深度偏差。 比恒定偏差更稳健的解决方案是使偏差取决于表面相对于光线的陡峭程度。较陡峭的多边形更容易产生粉刺,并且需要更大的偏差。较平坦的多边形需要较小的偏差。大多数图形API,包括WebGL,都提供了在深度传递期间自动应用此类偏差的功能,这通常优于片段着色器中的手动偏差。
透视锯齿(锯齿状边缘)
问题: 阴影的边缘看起来块状、锯齿状和像素化。这是一种锯齿形式。发生这种情况是因为阴影贴图的分辨率是有限的。阴影贴图中的单个像素(或纹素)可能会覆盖最终场景中表面上的大面积区域,特别是对于靠近相机或以掠射角度查看的表面。分辨率的这种不匹配会导致特征性的块状外观。
解决方案: 增加阴影贴图分辨率(例如,从 1024x1024 到 4096x4096)可以有所帮助,但它会带来显着的内存和性能成本,并且不能完全解决根本问题。真正的解决方案在于更先进的技术。
第四章:高级阴影贴图技术
基本的阴影贴图提供了基础,但专业应用程序使用更复杂的算法来克服其局限性,特别是锯齿。
百分比接近过滤 (PCF)
PCF 是用于柔化阴影边缘和减少锯齿的最常用技术。PCF 不是从阴影贴图中获取单个样本并做出二元(阴影中或不在阴影中)决定,而是从目标坐标周围的区域中获取多个样本。
概念: 对于每个片段,我们不仅采样阴影贴图一次,而且在片段的投影纹理坐标周围的网格图案(例如,3x3 或 5x5)中进行采样。对于这些样本中的每一个,我们都会执行深度比较。最终阴影值是所有这些比较的平均值。例如,如果 9 个样本中有 4 个在阴影中,则片段将有 4/9 的阴影,从而产生平滑的半影(阴影的柔和边缘)。
实现: 这完全在片段着色器中完成。它涉及一个循环,该循环迭代一个小的内核,在每个偏移处采样阴影贴图并累积结果。WebGL 2 提供了硬件支持(带有 `sampler2DShadow` 的 `texture`),可以更有效地执行比较和过滤。
优点: 通过用平滑、柔和的边缘替换硬的、锯齿状的边缘,大大提高了阴影质量。
成本: 性能会随着每个片段获取的样本数量的增加而降低。
级联阴影贴图 (CSM)
CSM 是用于在非常大的场景中从单个定向光源(如太阳)渲染阴影的行业标准解决方案。它直接解决了透视锯齿问题。
概念: 核心思想是,靠近相机的对象比远处的对象需要更高的阴影分辨率。CSM 沿着其深度将摄像机的视锥体划分为几个部分,或“级联”。然后为每个级联渲染单独的高质量阴影贴图。最靠近相机的级联覆盖了世界空间的一小部分区域,因此具有非常高的有效分辨率。更远的级联以相同的纹理大小覆盖逐渐更大的区域,这是可以接受的,因为这些细节对玩家来说不太明显。
实现: 这要复杂得多。
- 在 CPU 中,将相机视锥体划分为 2-4 个级联。
- 对于每个级联,计算光的紧密拟合正交投影矩阵,该矩阵完美地包含视锥体的该部分。
- 在渲染循环中,多次执行深度传递 - 每个级联一次,渲染到不同的阴影贴图(或纹理图集的区域)。
- 在最终场景传递片段着色器中,根据片段与相机的距离确定当前片段属于哪个级联。
- 采样适当的级联的阴影贴图以计算阴影。
优点: 在广阔的距离上提供始终如一的高分辨率阴影,使其非常适合户外环境。
方差阴影贴图 (VSM)
VSM 是另一种创建柔和阴影的技术,但它采用与 PCF 不同的方法。
概念: VSM 不是仅将深度存储在阴影贴图中,而是存储两个值:深度(第一时刻)和深度平方(第二时刻)。这两个值允许我们计算深度分布的方差。使用一种称为切比雪夫不等式的数学工具,我们可以估计片段在阴影中的概率。主要优点是 VSM 纹理可以使用标准的硬件加速线性过滤和mipmap进行模糊处理,这对于标准的深度图在数学上是无效的。这允许非常大,柔软且平滑的阴影半影,并具有固定的性能成本。
缺点: VSM 的主要弱点是“光溢出”,其中在具有重叠遮挡物的情况下,光似乎会透过物体渗出,因为统计近似可能会崩溃。
第五章:实用实施技巧和性能
选择您的阴影贴图分辨率
阴影贴图的分辨率是质量和性能之间的直接权衡。较大的纹理提供更清晰的阴影,但会消耗更多的视频内存,并且需要更长的时间来渲染和采样。常见尺寸包括:
- 1024x1024: 许多应用程序的良好基线。
- 2048x2048: 为桌面应用程序提供明显的质量改进。
- 4096x4096: 高质量,通常用于英雄资源或具有强大剔除功能的引擎。
优化光线的视锥体
为了充分利用阴影贴图中的每个像素,至关重要的是,光线的投影体积(其正交框或透视视锥体)尽可能紧密地贴合到需要阴影的场景元素。对于定向光,这意味着调整其正交投影以仅包含相机视锥体的可见部分。阴影贴图中任何浪费的空间都是浪费的分辨率。
WebGL 扩展和版本
WebGL 1 vs. WebGL 2: 虽然阴影贴图在 WebGL 1 中是可能的,但在 WebGL 2 中要容易得多且效率更高。WebGL 1 需要 `WEBGL_depth_texture` 扩展来创建深度纹理。WebGL 2 内置了此功能。此外,WebGL 2 提供了对阴影采样器 (`sampler2DShadow`) 的访问,它可以执行硬件加速的 PCF,从而比着色器中的手动 PCF 循环提供显着的性能提升。
调试阴影
众所周知,阴影很难调试。最有效的技术是可视化阴影贴图。暂时修改您的应用程序以将来自特定光源的深度纹理直接渲染到屏幕上的四边形上。这允许您准确地看到光“看到”的内容。这可以立即揭示光线的矩阵、视锥体剔除或深度传递期间的对象渲染的问题。
结论
实时阴影贴图是现代 3D 图形的基石,将扁平、毫无生气的场景转变为可信且动态的世界。虽然从光线的角度进行渲染的概念很简单,但要获得高质量、无伪影的结果需要深入了解底层机制,从两遍管线到深度偏差和锯齿的细微差别。
从基本实现开始,您可以逐步解决常见的伪影,如阴影粉刺和锯齿状边缘。从那里,您可以使用高级技术(如 PCF 获得柔和阴影,或级联阴影贴图获得大规模环境)来提升视觉效果。阴影渲染之旅是艺术和科学融合的完美示例,这使得计算机图形如此引人入胜。我们鼓励您尝试这些技术,突破它们的界限,并为您的 WebGL 项目带来新的真实感。